Skip to content

Fix #201: re-fold the HRV baseline on a window switch instead of restarting calibration - #207

Closed
digitalerdude wants to merge 1 commit into
ryanbr:mainfrom
digitalerdude:fix/201-hrv-window-refold-no-recalibrate
Closed

digitalerdude wants to merge 1 commit into
ryanbr:mainfrom
digitalerdude:fix/201-hrv-window-refold-no-recalibrate

Conversation

@digitalerdude

Copy link
Copy Markdown

Follow-up to #195 / implements #201.

Problem

Switching the HRV window (Whole-night ↔ Deep-sleep) in Settings re-anchored the HRV baseline epoch to now. That drops all history and restarts the ≥4-night calibration, so a user with plenty of nights fell back to "calibrating" for several nights and Charge went nilScore. That reads as "the deep-sleep setting is broken" (#195).

Why the reset is unnecessary

The same handler already forces a re-score, and analyzeRecent:

  1. recomputes the recent ~21 nights' avgHrv under the new window, deterministically from stored R-R, and
  2. re-folds the HRV baseline from those same nights in the same pass (baselines2).

The HRV baseline is a 14-night-half-life EWMA (Baselines.metricCfg["hrv"]), so it's dominated by that fresh, just-re-scored tail. A plain re-score therefore already lands the baseline on the new window for anyone with ≥ minNightsSeed valid nights — no artificial calibration reset needed.

Change

  • Drop the hrvBaselineEpoch = now write from both window-switch handlers (Strand/Screens/SettingsView.swift, android/.../ui/SettingsScreen.kt); keep the forced re-score (Android also keeps the analyzeWatermark clear so the re-score runs even though the raw HR fingerprint is unchanged).
  • Update the picker subtitle + the Android toast to say the switch takes effect right away once you have a few nights of data.
  • The manual "Recalibrate Charge baseline" button is untouched — it still resets both epochs on purpose.

Parity & scope

  • Both platforms changed together (parity contract).
  • A genuine cold-start user (< minNightsSeed valid nights) still calibrates honestly — expected, per the issue.
  • Known nuance for the reviewer: a user with many imported whole-night nights but very few computed nights could still see a brief skew (the imported tail isn't re-windowed) — but they'd be calibrating anyway with < 4 computed nights. Not addressed here on purpose.

Verification

  • swift test — StrandAnalytics BaselinesTests 22/22 green, including the new testWindowSwitchRefoldStaysUsableWithoutEpochReset.
  • Strand macOS app target compiles (app-build.yml is disabled, so app-target Swift has no default CI).
  • Added an Android parity test (HrvBaselineRecalibrationTest.windowSwitchRefold_staysUsableWithoutEpochReset) mirroring the Swift one; not run locally (no Android SDK) — relying on android.yml.
  • Still needs on-device confirmation across several nights, per the issue: switch the window with ≥4 nights of history and confirm Charge stays scored (does not drop to calibrating) and the HRV trend reflects the new window.

🤖 Generated with Claude Code

…f restarting calibration

Switching the HRV window (Whole-night ↔ Deep-sleep) re-anchored the HRV
baseline epoch to now, which drops all history and restarts the ≥4-night
calibration — so a user with plenty of nights fell back to "calibrating" for
several nights and Charge went nil. That read as "the deep-sleep setting is
broken" (ryanbr#195).

analyzeRecent already re-scores the recent ~21 nights' avgHrv under the new
window AND re-folds the HRV baseline from them in the same pass, and the
baseline's 14-night-half-life EWMA is dominated by that fresh re-scored tail.
So the epoch reset is unnecessary: drop it from both window-switch handlers
(SettingsView / SettingsScreen), keep the forced re-score. An established user
(≥ minNightsSeed valid nights) now sees the switch take effect immediately; a
genuine cold-start user still calibrates honestly. The manual "Recalibrate
Charge baseline" button is untouched — it still resets both epochs on purpose.

Updates the picker copy + Android toast to match, and adds a pure fold
regression test on both platforms (Swift BaselinesTests + Android
HrvBaselineRecalibrationTest) pinning that re-scored new-window nights fold to a
usable baseline WITHOUT an epoch reset.

Verification: swift test (StrandAnalytics BaselinesTests, 22/22) green; Strand
macOS app target compiles (app-build CI is disabled). Android JVM test mirrors
the Swift one but wasn't run locally (no Android SDK). HRV baseline behaviour
across several nights still needs on-device confirmation, per the issue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ryanbr

ryanbr commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Thanks — reviewed and landed on main as f890e41 (#209) under the project identity, with the analysis verified end-to-end through the two-pass fold (baselines2.hrv honours the unchanged hrvBaselineEpoch, and old computed nights aren't in the fold set, so a plain re-score lands the baseline on the new window with no old-window skew). Both platform tests are green in CI. Still wants the on-device confirmation you noted (switch the window with ≥4 nights and confirm Charge stays scored). Closing as superseded.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants